If, else, Logic, and Laziness

These commands are Pythons bread and butter! You would do well to pay attention to this lecture because 'if statements' are both very common and very very useful.

There are a few ways to use the if statement within your code, and each way has slightly different syntax. For now, we are going to focus on in-line expressions.

{value} if {condition} else {value_2}  
* Caveat: {condition} must evaluate to a boolean value (True or False).

This code will return {value} if the condition is True. If the condition is False we return whatever is in the "else" bit of the code, i.e. {value_2}. To restate the logic in normal English:

"If the condition is true we do {this} but if the condition is False we do {that}".

Okay, lets show you how this works with an example. You guys remember how 'input' works, right?


In [1]:
# Takes a number as input, prints whether that number is divisible by 4.
text = input("Give me integer... ")
                                     
result = "{} is{}divisible by 4".format(text, "" if int(text) % 4 == 0 else " NOT ")
#                                              ^ this is the important bit here ^
print(result)


Give me integer... 89
89 is NOT divisible by 4

I would recommend you call this function a few times, to ensure you understand it. Notice that "result" has different values depending on whether the number you gave is/is not divisible by 4.

Notice above when I was speaking of syntax I said the conditions have to be True/False. Let me just quickly show you that it is the case here:


In [2]:
print (10 % 4 == 0)
print (16 % 4 == 0)

# Note: "==" is NOT to be confused with "="


False
True

Logic Operators...

Before moving on, I should probably give you a bit more Python vocabulary:

 SYMBOL + SYNTAX           ::   MEANING    ::               :: EXAMPLE ::                  

     if a                if a exists               if a print(a)                                
     if not a            if a does not exist       if not a print("NOOO!!!!")                    
     a == b              is a equal to b           10 == 10 is True, 5 == 10 is False.            
     a != b              is a not equal to b       10 != 10 is False, 5 != 10 is True.           
     a > b               is a greater than b       10 > 5 is True, 10 > 5 is False               
     a < b               is a less than b          10 < 5 is False, 5 < 10 is True               
     a >= b              is a greater or equal b   10 >= 10 is True                              
     a <= b              is a less or equal b      10 <= 10 is True                              


The above table has a bunch of logical operators, with meanings and examples. For example, ‘is a equal to b?’ which is the ‘==’ symbol. If you remember only one thing from today's lesson please let it be '=='. It comes up a lot, and I mean A LOT.

Anyway, I suspect the most complex of these commands to grasp is the simple 'if a'. Below I have a few more test cases to help you understand how it works. I also have a section on readability today, which will help explain why you will frequently see code like if a != [ ] rewritten as if a*.


In [3]:
def exists(x): 
    return True if x else False
    
print ("if 1 equates to...", exists(10))
print ("if \"\" equates to...", exists(""))    # empty string
print ("if 0 equates to...", exists(0))        # remember True/False are 1/0 in Python
print ("if [] equates to...", exists([]))      # empty list
print ("if [0] equates to...", exists([0]))    # list contains 0, therefore list not empty
print ("if [False] equates to...", exists([False]))


if 1 equates to... True
if "" equates to... False
if 0 equates to... False
if [] equates to... False
if [0] equates to... True
if [False] equates to... True

Why does '[0]' equal True and '[]' equal False? The answer is that when we ask Python ‘if a exists’ Python decides that statement is True if a is NOT empty. In other words, an empty string/list is basically the same as not existing and hence returns False. In this particular case exists([0]) is True because the list contains the element "0". This is also why [False] returns True; its True because the list contains the element "False".

The way this works is a bit tricky to get your hand around, but once you do get it you can start writing some really nice idiomatic code.

Readibility counts...

This is a minor detour, but I feel that I should not be simply teaching you guys how to do stuff, I should be trying to teach you guys how to do stuff in the most 'Pythonic' way possible. This is a good juncture to talk a little about style. Consider the following code:

if variable != []:
    {do something} 

OR:

if variable == True:
    {do something}    

The first code snippet asks if 'variable' is an empty list. If it isn't empty we enter the main body of code and do something (see indentation lecture). The second snippet of code asks if our value is equal to the value True. In many cases code like this code can be written to be more 'Pythonic'. You see, both these statements are essentially asking 'if variable exists do X', which means we can refactor this to:

if variable:
    {do something}

Alright, let's try another example...

if variable == {value}:
    return True
else:
    return False

So this code is part of a function. The function returns True if the variable is equal to some value and returns False otherwise. Just as before it is important to note that this code works, BUT, it can be rewritten like this:

return True if variable == {value} else False

But guess what, we can further refactor this code, since "==" always returns a boolean (ie. True/False) the "True if" is simply not necessary. So even better is:

return variable == {value}

Thats four lines of code condensed into one simple expression. Neat huh? Alright, that's enough about readability for today’s lecture, lets move on.

Understanding the operators...

In the code window below I have written a bit of code that will ask you for two variables (a, b) and an operator. It will then tell you whether that condition is True or False.

For example:

a is 10 b is 16 operator is >=

In which case, the code will figure out whether 10 >= 16 is True or False. I'd recommend calling this code a few times with different inputs in order to get a proper feel for what's going on.


In [9]:
a = input("\nGive me variable 'a' :                         ")
b = input("Now give me variable 'b' :                     ")
op = input("give me an operator (e.g '==', '!=', '>') :    ")

string = "{} {} {}".format(a, op, b)
print("\nThe statement is {}...".format(string), "The statement is {}".format(eval(string)), sep="\n   ")


Give me variable 'a' :                         90
Now give me variable 'b' :                     90
give me an operator (e.g '==', '!=', '>') :    >

The statement is 90 > 90...
   The statement is False

And & Or

Now we are going to add to the complexity a little by explaining how we can combine expressions into larger ones. Why might we want to do this?

Well say for instance you want to write some code that returns a number that is divisible by 5 OR divisible by 10. Maybe you want to write some code that checks if a number is odd AND also a perfect square (eg. 9, 25).

As it turns out, Python has the "and"/"or" commands and for the most part they will work how we understand them in English. But, perhaps we should make the effort to be precise. The table below tells you what the output of the operator is for all values of A and B.

Okay, cool. So how do we use and/or in Python? The good news is that syntax is super intuitive:

{value} and {value_2}    returns True/False
{value} or  {value_2}    returns True/False

yes thats right, the keyword for 'and' in Python is the word 'and', 'or' in Python is also the same as English. Alright, lets run a few examples shall we:


In [5]:
x = 10

print(x > 9 and x < 11)  # is x greater than 9 AND less than 11. Note, we could refactor this to: 11 > x > 9
print( isinstance(x, str) or isinstance(x, int))   # is x a string OR a integer

print(x % 5 == 0 and x % 2 ==0) # is x divisible by 5 AND 2

# Once again, remember 0 = False and 1 = True
print(0 or 0)  # False or False  = False
print(1 or 0)  # True or False   = True
print(0 and 0) # False AND False = False
print(0 and 1) # False AND True  = False

a = True
# Basic logic...
print(a or not a)  # Tautology, ALWAYS TRUE  (for any a)
print(a and not a) # Contraction, AlWAYS FALSE (for any a)


True
True
True
0
1
0
0
True
False

A note on Python's "Lazy" evaluation...

Lets play a simple game. The rules:

  1. I give you two statements, P, Q and an operator (operator will always be one of "and/or")
  2. Both statements are truth functional (i.e. statements that are True or False)
  3. Your job is to evaluate the expression (P operator Q) in the fastest time possible.
  4. You get to choose what to evaluate first, i.e. your options are P then Q, or Q then P.

Alright, here's the first question:

  • P = "There are an infinite number of twin primes"
  • Q = "7 + 7 is an odd number"
  • operator = And

So, to once again reiterate the question:

Is (P and Q) True/False and what is the fastest strategy for solving it?

Answer:

The statement (P and Q) is False, and the fastest strategy is to calculate statement Q first.

Why? Well, in order to prove that (P and Q) is False we only have to prove that either P or Q is False. In this case, its pretty easy to see that Q is false, therefore we know the answer of (P and Q) without having to prove the twin primes conjecture.

Alright, lets try one more time.

Answer:

“The statement (P or Q) is True, and the fastest strategy is to calculate statement P first."

Similar to the problem above, to prove (P or Q) is True we only have to prove either P or Q is True. In this case, we can quickly check 7 is prime and thus we can solve the problem without even attempting to solve the np=p problem (which btw, has a million dollar prize for the first person to prove it).

Okay, so how can we use this information in our Python programmes? Well Python uses the same "lazy" evaluation as we did above; if we know A is True we don't have to calculate B in order to know (A or B) is True.

So how can we take advantage of this? Well, the syntax is simple, Python evaluates left-to-right. So in the case of P or Q Python ALWAYS checks P first. To take advantage then, what we should do is give Python the easiest statement first and then the slower one.

In the case of the first problem all we would have to do to make Python solve it as fast as we did is to give python (Q and P) (in that order).

The code below attempts to convince you of this...


In [14]:
import time

def timesink_function(x):
    """
    Returns x, after 5 secs
    """
    time.sleep(5)
    return x


## Experiment # 1
print("True or wait(true)...")
t1 = time.time()
_  = True or timesink_function(True) # the important line
t2 = time.time()
print("Time Taken was {} secs".format(round(t2-t1, 1)))
print("\n")

## Experiment # 2
print("wait(true) or true...")
t1 = time.time()
_  = timesink_function(True) or True # the important line
t2 = time.time()
print("Time Taken was {} secs".format(round(t2-t1, 1)))
print("\n")


True or wait(true)...
Time Taken was 0.0 secs


wait(true) or true...
Time Taken was 5.0 secs


So in the above two experiments notice that in both cases the timesink_function returns True (after waiting 5 secounds). The only difference between the two experiments is the order in which we make the calls (true or wait, wait or true). Notice that wait or true took 5 secs to evaluate, whereas 'true or wait' took 0 secs.


In [15]:
## Experiment 3
print("False or wait(true)...")
t1 = time.time()
_  = False or timesink_function(True) # the important line
t2 = time.time()
print("Time Taken was {} secs".format(round(t2-t1, 1)))
print("\n")

## Experiment 4
print("wait(true) or False...")
t1 = time.time()
_  = timesink_function(True) or False # the important line
t2 = time.time()
print("Time Taken was {} secs".format(round(t2-t1, 1)))
print("\n")


False or wait(true)...
Time Taken was 5.0 secs


wait(true) or False...
Time Taken was 5.0 secs


In experiments 3 and 4 we are comparing 'False or wait' against 'wait or false'. In this case there is no difference in time. Thats because the Truth of 'False or X' or 'X or False' actually simplifies to 'X'. Python cannot take any shortcuts here, it has to workout what X is, and that takes 5 secs.


In [16]:
## Experiment 5
print("False and wait(true)...")
t1 = time.time()
_  = False and timesink_function(True) # the important line
t2 = time.time()
print("Time Taken was {} secs".format(round(t2-t1, 1)))
print("\n")

## Experiment 6
print("wait(true) and False...")
t1 = time.time()
_  = timesink_function(True) and False # the important line
t2 = time.time()
print("Time Taken was {} secs".format(round(t2-t1, 1)))
print("\n")


False and wait(true)...
Time Taken was 0.0 secs


wait(true) and False...
Time Taken was 5.0 secs


In Experiments 5 and 6 are just like 3 and 4, except that we are not looking at the "and" condition. There is once again a time difference. Python gets to be lazy in one case but not in the other.

In short, understanding Python’s lazy evaluation can speed up you code considerably (without any cost to readability). As a general rule, to make use of lazy evaluation all you need to do is put the thing that is fastest to evaluate first and the slowest thing last. For maximum efficiency you would need to figure out what it minimum about of work you need to do in order to arrive at an answer.

Homework

Your challenge this week is to evaluate the following formula as fast as possible:

(a or b) and ( (b and not b) or c )

Where:

  • a takes 2 seconds to return a true/false value
  • b takes 3 seconds to return a true/false value
  • c takes 4 seconds to return a true/false value

Have a bit of play and see how quickly you can make it execute.

Just be careful not to change the meaning of the expression. The parenthesis, just like in maths group the elements. For example "(a or b) and c" is not the same as "a or (b and c)".


In [34]:
import time
import random
from functools import partial


def sleep_bool(x, b):
    """
    waits for x secs, then returns true/false
    """
    time.sleep(x)
    return b


a = partial(sleep_bool, 2, True) 
b = partial(sleep_bool, 3, False)
c = partial(sleep_bool, 4, False) 


t1 = time.time()
expression = ( a() or b() ) and ( (b() and not b() ) or c() )    ## <-- change this line
t2 = time.time()

print("Time Taken was {} secs".format(round(t2-t1, 1)))
print("\n")

## Can you beat 9 secs ???


Time Taken was 9.0 secs



In [ ]: